RTOS 系统的核心是任务管理,而任务管理的核心是任务切换,任务切换决定了任务的执行顺序,任务切换效率的高低也决定了一款系统的性能, 尤其是对于实时操作系统。 想深入了解 FreeRTOS 系统运行过程,任务切换是必须的。

  • PendSV 异常
  • FreeRTOS 任务切换场合
  • PendSV 中断服务函数
  • 查找下一个要运行的任务

PendSV 异常

PendSV(可挂起的系统调用)异常对 OS 操作非常重要,其优先级可以通过编程设置。可以通过将中断控制和壮态寄存器 ICSR 的 bit28,也就是 PendSV 的挂起位置 1 来触发 PendSV 中断。 与 SVC 异常不同,它是不精确的,因此它的挂起壮态可在更高优先级异常处理内设置,且会在高优先级处理完成后执行。

利用该特性,若将 PendSV 设置为最低的异常优先级,可以让 PendSV 异常处理在所有其他中断处理完成后执行,这对于上下文切换非常有用,也是各种 OS 设计中的关键。

在具有嵌入式 OS 的典型系统中,处理时间被划分为了多个时间片。若系统中只有两个任务,这两个任务会交替执行

上下文切换被触发的场合:

  • 执行一个系统调用
  • 系统滴答定时器(SysTick)中断

在 OS 中,任务调度器决定是否应该执行上下文切换,每次它都会决定切换到一个不同的任务中。

若中断请求(IRQ)在 SysTick 异常前产生,则 SysTick 异常可能会抢占 IRQ 的处理,在这种情况下, OS 不应该执行上下文切换,否则中断请求 IRQ 处理就会被延迟,而且在真实系统中延迟时间还往往不可预知——任何有一丁点实时要求的系统都决不能容忍这种事。对于 CortexM3 和Cortex-M4 处理器,当存在活跃的异常服务时,设计默认不允许返回到线程模式, 若存在活跃中断服务,且 OS 试图返回到线程模式,则将触发用法 fault。

PendSV 异常将上下文切换请求延迟到所有其他 IRQ 处理都已经完成后,此时需要将 PendSV 设置为最低优先级。若 OS 需要执行上下文切换,他会设置 PendSV 的挂起壮态,并在 PendSV 异常内执行上下文切换。

(1) 任务 A 呼叫 SVC 来请求任务切换(例如,等待某些工作完成)
(2) OS 接收到请求,做好上下文切换的准备,并且 pend 一个 PendSV 异常。
(3) 当 CPU 退出 SVC 后,它立即进入 PendSV,从而执行上下文切换。
(4) 当 PendSV 执行完毕后,将返回到任务 B,同时进入线程模式。
(5) 发生了一个中断,并且中断服务程序开始执行
(6) 在 ISR 执行过程中,发生 SysTick 异常,并且抢占了该 ISR。
(7) OS 执行必要的操作,然后 pend 起 PendSV 异常以作好上下文切换的准备。
(8) 当 SysTick 退出后,回到先前被抢占的 ISR 中, ISR 继续执行
(9) ISR 执行完毕并退出后, PendSV 服务例程开始执行,并且在里面执行上下文切换。
(10) 当 PendSV 执行完毕后,回到任务 A,同时系统再次进入线程模式。

FreeRTOS 系统的任务切换最终都是在 PendSV中断服务函数中完成的, UCOS 也是在 PendSV 中断中完成任务切换的。

FreeRTOS 任务切换场合

执行系统调用

执行系统调用就是执行 FreeRTOS 系统提供的相关 API 函数,比如任务切换函数 taskYIELD(),FreeRTOS 有些 API 函数也会调用函数 taskYIELD(),这些 API 函数都会导致任务切换,这些 API 函数和任务切换函数 taskYIELD()都统称为系统调用。 函数 taskYIELD()其实就是个宏,在文件 task.h中有如下定义:

1
#define     taskYIELD()    portYIELD( )

函数 portYIELD()也是个宏,在文件 portmacro.h 中有如下定义:

1
2
3
4
5
6
7
#define portYIELD() 
{
portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT; (1)

__dsb( portSY_FULL_READ_WRITE );
__isb( portSY_FULL_READ_WRITE );
}

(1)、通过向中断控制和壮态寄存器 ICSR 的 bit28 写入 1 挂起 PendSV 来启动 PendSV 中断。这样就可以在 PendSV 中断服务函数中进行任务切换了。

中断级的任务切换函数为 portYIELD_FROM_ISR(),定义如下:

1
2
3
4
#define  portEND_SWITCHING_ISR( xSwitchRequired ) \
if( xSwitchRequired != pdFALSE ) portYIELD()

#define portYIELD_FROM_ISR( x ) portEND_SWITCHING_ISR( x )

可以看出 portYIELD_FROM_ISR()最终也是通过调用函数 portYIELD()来完成任务切换的。

系统滴答定时器(SysTick)中断

FreeRTOS 中滴答定时器(SysTick)中断服务函数中也会进行任务切换,滴答定时器中断服务
函数如下:

SysTick_Handler(void)
1
2
3
4
5
6
{
if(xTaskGetSchedulerState()!=taskSCHEDULER_NOT_STARTED) //系统已经运行
{
xPortSysTickHandler();
}
}

在滴答定时器中断服务函数中调用了 FreeRTOS 的 API 函数 xPortSysTickHandler(),此函数
源码如下:

xPortSysTickHandler( void )
1
2
3
4
5
6
7
8
9
10
{
vPortRaiseBASEPRI(); (1)
{
if( xTaskIncrementTick() != pdFALSE ) //增加时钟计数器 xTickCount 的值
{
portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT; (2)
}
}
vPortClearBASEPRIFromISR(); (3)
}

(1)、关闭中断
(2)、通过向中断控制和壮态寄存器 ICSR 的 bit28 写入 1 挂起 PendSV 来启动 PendSV 中
断。这样就可以在 PendSV 中断服务函数中进行任务切换了。
(3)、打开中断。

PendSV 中断服务函数

FreeRTOS 任务切换的具体过程是在 PendSV 中断服务函数中完成的。
PendSV 中断服务函数本应该为 PendSV_Handler(),但是 FreeRTOS 使用#define 重定义了,如下:

1
#define   xPortPendSVHandler   PendSV_Handler

函数 xPortPendSVHandler()源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
__asm void xPortPendSVHandler( void )
{
extern uxCriticalNesting;
extern pxCurrentTCB;
extern vTaskSwitchContext;
PRESERVE8
mrs r0, psp (1)
isb

ldr r3, =pxCurrentTCB (2)
ldr r2, [r3]
(3)
stmdb r0!, {r4-r11, r14} (4)
str r0, [r2] (5)
stmdb sp!, {r3,r14} (6)
mov r0, #configMAX_SYSCALL_INTERRUPT_PRIORITY (7)
msr basepri, r0 (8)
dsb
isb
bl vTaskSwitchContext (9)
mov r0, #0 (10)
msr basepri, r0 (11)
ldmia sp!, {r3,r14} (12)

ldr r1, [r3] (13)
ldr r0, [r1] (14)

ldmia r0!, {r4-r11} (15)

msr psp, r0 (16)
isb
bx r14 (17)
nop
}

查找下一个要运行的任务

PendSV 中断服务程序中有调用函数 vTaskSwitchContext()来获取下一个要运行的任务,也就是查找已经就绪了的优先级最高的任务

调用函数 taskSELECT_HIGHEST_PRIORITY_TASK()获取下一个要运行的任务。taskSELECT_HIGHEST_PRIORITY_TASK()本质上是一个宏,在 tasks.c 中有定义。

FreeRTOS 中查找下一个要运行的任务有两种方法:一个是通用的方法,另外一个就是使用硬件的方法,这个在我们讲解 FreeRTOSCofnig.h 文件的时候就提到过了,至于选择哪种方法通过宏 configUSE_PORT_OPTIMISED_TASK_SELECTION 来决定的。当这个宏为 1 的时候就使
用硬件的方法,否则的话就是使用通用的方法。

1、通用方法

就是所有的处理器都可以用的方法,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define taskSELECT_HIGHEST_PRIORITY_TASK() \
{
UBaseType_t uxTopPriority = uxTopReadyPriority; \

while( listLIST_IS_EMPTY( &( pxReadyTasksLists[ uxTopPriority ] ) ) ) \ (1)
{ \
configASSERT( uxTopPriority ); \
uxTopPriority; \
} \

listGET_OWNER_OF_NEXT_ENTRY( pxCurrentTCB, \ (2)
&( pxReadyTasksLists[ uxTopPriority ] ) ); \

uxTopReadyPriority = uxTopPriority; \
}

(1)、 在前面的 8.2.4 小节中说了 pxReadyTasksLists[]为就绪任务列表数组,一个优先级一个列表,同优先级的就绪任务都挂到相对应的列表中。 uxTopReadyPriority 代表处于就绪态的最高优先级值,每次创建任务的时候都会判断新任务的优先级是否大于 uxTopReadyPriority,如果大于的话就将这个新任务的优先级赋值给变量 uxTopReadyPriority。 函数 prvAddTaskToReadyList()也会修改这个值,也就是说将某个任务添加到就绪列表中的时候都会用 uxTopReadyPriority 来记录就绪列表中的最高优先级。 这里就从这个最高优先级开始判断,看看哪个列表不为空就说优先级有就绪的任务。函数 listLIST_IS_EMPTY() 用于判断某个列表是否为空,uxTopPriority 用来记录这个有就绪任务的优先级。

(2)、 已经找到了有就绪任务的优先级了,接下来就是从对应的列表中找出下一个要运行的任务,查找方法就是使用函数 listGET_OWNER_OF_NEXT_ENTRY()来获取列表中的下一个列表项,然后将获取到的列表项所对应的任务控制块赋值给 pxCurrentTCB,这样我们就确定了下一个要运行的任务了。

可以看出通用方法是完全通过 C 语言来实现的,肯定适用于不同的芯片和平台,而且对于任务数量没有限制,但是效率肯定相对于使用硬件方法的要低很多。

硬件方法

硬件方法就是使用处理器自带的硬件指令来实现的,比如 Cortex-M 处理器就带有的计算前导 0 个数指令: CLZ,

FreeRTOS 时间片调度

FreeRTOS 支持多个任务同时拥有一个优先级,在 FreeRTOS 中允许一个任务运行一个时间片(一个时钟节拍的长度)后让出 CPU 的使用权,让拥有同优先级的下一个任务运行,FreeRTOS 中的这种调度方法就是时间片调度。

要使用时间片调度的话宏configUSE_PREEMPTION 宏 configUSE_TIME_SLICING 必须为 1。时间片的长度由宏 configTICK_RATE_HZ 来确定,一个时间片的长度就是滴答定时器的中断周期,比如本教程中 configTICK_RATE_HZ 为 1000,那么一个时间片的长度就是 1ms。时间片调度发生在滴答定时器的中断服务函数中,前面讲解滴答定时器中断服务函数的时候说了在中断服务函数 SysTick_Handler()中会调用 FreeRTOS 的 API 函数 xPortSysTickHandler(),而函数 xPortSysTickHandler() 会引发任务调度,但是这个任务调度是有条件的 , 函数xPortSysTickHandler()如下:

1
2
3
4
5
6
7
8
9
10
11
void xPortSysTickHandler( void )
{
vPortRaiseBASEPRI();
{
if( xTaskIncrementTick() != pdFALSE )
{
portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT;
}
}
vPortClearBASEPRIFromISR();
}

函数 xTaskIncrementTick()的返回值不为 pdFALSE 的时候就会进行任务调度!